package risoe.syslab.fhnew.controller;

/*
 * Copyright (c) 2012-2014, Technical University of Denmark (DTU)
 * All rights reserved.
 * 
 * The Flexhouse 2.0 platform ("this software") is licensed under the
 * BSD 3-clause license.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *   - Redistributions of source code must retain the above copyright
 *     notice, this list of conditions and the following disclaimer.
 *   - Redistributions in binary form must reproduce the above copyright
 *     notice, this list of conditions and the following disclaimer in the
 *     documentation and/or other materials provided with the distribution.
 *   - Neither the name of DTU nor the names of its contributors may be used to
 *     endorse or promote products derived from this software without specific
 *     prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE TECHNICAL UNIVERSITY OF DENMARK BE LIABLE FOR ANY
 * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

import java.io.File;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.HashMap;
import java.util.TimeZone;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

import org.w3c.dom.Document;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.w3c.dom.svg.SVGDocument;

import risoe.syslab.fhnew.common.SystemConstants.DeviceType;
import risoe.syslab.fhnew.config.ActuatorConfig;
import risoe.syslab.fhnew.config.BuildingConfig;
import risoe.syslab.fhnew.config.RoomConfig;
import risoe.syslab.fhnew.config.SensorConfig;
import risoe.syslab.fhnew.db.LoggerManager;
import risoe.syslab.fhnew.gui.DocumentPreparedListener;
import risoe.syslab.fhnew.gui.VisualizationEvent;
import risoe.syslab.fhnew.gui.VisualizationEvent.EventType;
import risoe.syslab.fhnew.gui.VisualizationEventListener;
import risoe.syslab.fhnew.gui.VisualizationHolder;
import risoe.syslab.fhnew.gui.VisualizationUpdate;
import risoe.syslab.fhnew.gui.widgets.TimePlot;
import risoe.syslab.fhnew.hw.Sensor;
import risoe.syslab.model.CompositeMeasurement;
import risoe.syslab.model.CompositeString;
import risoe.syslab.model.flexhouse.ControllerParameter;

/**
 * The default thermostatic controller for powerflexhouse 3.
 * @author M. Svendsen
 */
public class DefaultThermostaticController extends DefaultController {
	// Configuration of controller
	private enum ControlType{
		Automatic, Manual, Off
	}
	
	// Constants
	public final static int MINUTE_IN_MILLIS = 60000;
	private final static int SETPOINTS_PER_DAY = 1440;
	private final static float MIN_ACTUATION = 0.0f;
	private final static float MAX_ACTUATION = 1.0f;
	public final static double ERROR_MISSING_UPDATES = -999;

	// Containers
	private static ConcurrentHashMap<String, double[]> mSetPointsMap = new ConcurrentHashMap<String, double[]>();
	private static ConcurrentHashMap<String, Double> mCurrentSetPointsMap = new ConcurrentHashMap<String, Double>();
	private static HashMap<String, ControllerState> mControllerMap = new HashMap<String, ControllerState>();

	// For updating setpoints according to time of day (UTC)
	private final static Calendar mCalendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
	private ScheduledExecutorService mExecutor = Executors.newSingleThreadScheduledExecutor();
	private Runnable mTask = new Runnable() {
		@Override
		public void run() {
			// Update all setpoints
			for (String key : mCurrentSetPointsMap.keySet()){
				// Get the index of the current minute
				mCalendar.setTimeInMillis(System.currentTimeMillis());
				int index = mCalendar.get(Calendar.HOUR_OF_DAY)*60+mCalendar.get(Calendar.MINUTE);

				// Get the new value and update the current setpoint
				Double oldValue = mCurrentSetPointsMap.get(key);
				double newValue = mSetPointsMap.get(key)[index];

				if (oldValue != newValue){
					mCurrentSetPointsMap.put(key, newValue);

					// Log change in setpoint
					mLoggermanager.getHouseEventLogger().logEvent(DefaultThermostaticController.class.getSimpleName(), 
							"Setpoint Update", "Thermostatic setpoint in "+key+" changed to: "+newValue);
				}
			}
		}
	};

	// Values for the PID controllers
	private double Kp = 0.5317;
	private double Ti = 20.0;
	private double Td = 2.5;
	private double Tt = (Ti + Td)/2;	
	private double Ki = Kp/Ti;
	private double Kd = Kp*Td;

	private LoggerManager mLoggermanager;
	private ThermostaticVisualizationHandler mVisualizationHandler;

	public DefaultThermostaticController(ControllerManager manager, BuildingConfig buildingConfig) {
		super(manager, buildingConfig);
		mLoggermanager = manager.getLoggerManager();
		mControllerParameters.add(new ControllerParameter(getClass().getSimpleName(), "Temperature_Setpoint", "A setpoint", "double", "degrees C"));

		// Init controllers and setpoints for all rooms
		ArrayList<RoomConfig> rooms = mBuildingConfig.getRooms();
		for (RoomConfig room : rooms){
			String rname=room.getRoomName();
			mControllerMap.put(rname, new ControllerState(room));

			// Initialize setpoints
			double[] setPoints = new double[SETPOINTS_PER_DAY];
			for (int i=0; i<SETPOINTS_PER_DAY; i++){
				// Temperature is lowered between 8PM and 4AM UTC (Meaning between 10PM and 6AM)
				if (i > 240 && i < 1200){
					setPoints[i] = 22.0;
				}
				else{
					// The nighttime temperature
					setPoints[i] = 20.0;
				}
			}
			mSetPointsMap.put(rname, setPoints);

			mCurrentSetPointsMap.put(rname, 22.0);
		}

		// Schedule updates
		mExecutor.scheduleWithFixedDelay(mTask, 0, 1, TimeUnit.MINUTES);
	}

	@Override
	public void kill() {
		// The controllermanager has scheduled a kill command for this controller. 
		// Clean up
		mExecutor.shutdownNow();
	}

	@Override
	public VisualizationHolder getVisualization() {
		// Start visualization
		VisualizationHolder holder = new VisualizationHolder(DefaultThermostaticController.class.getSimpleName(), new File(ThermostaticVisualizationHandler.PATH).toURI());
		mVisualizationHandler = new ThermostaticVisualizationHandler(holder);
		return holder;
	}

	@Override
	protected void controllerParameterUpdated(ControllerParameter parameter,
			CompositeString value) {
		if (parameter.getParameterName().equals("Temperature_Setpoint")){
			// Update setpoint and turn OFF automatic control
			mVisualizationHandler.changeControlType(ControlType.Manual);
			for (RoomConfig room : mBuildingConfig.getRooms()){
				changeSetPoint(room.getRoomName(), Double.valueOf(value.value));
			}
		}
	}

	@Override
	public void sensorUpdated(Sensor s) {
		/** If this sensor is a temperature sensor, actuate heaters based on controlsignal */
		if (s.getType() == DeviceType.Temperature && !s.getName().contains("probe")){
			RoomConfig room = mBuildingConfig.getRoomForSensor(s.getName());
			CompositeMeasurement tm=s.getValue();
			if (tm.isGoodProcessValue()) {
				ControllerState controller = mControllerMap.get(room.getRoomName());

				// Control heators to assure wanted temperature
				double output = controller.update(s, tm.value);
				
				// Notify visualization
				mVisualizationHandler.temperatureReceived(room.getRoomName(), controller.getAverageTemperature());

				// Check for missing sensor updates
				if (output == ERROR_MISSING_UPDATES) return;

				// output contains a control signal, apply it to the heaters in this room
				for (ActuatorConfig actuator : room.getActuators()){
					if (actuator.getType() == DeviceType.PowerControlledHeater){
						setActuator(actuator.getName(), new CompositeMeasurement(output));
//						System.out.println("Changed Actuation 
//value for: "+actuator+", to: "+output+" current temp: "+tm.value);
					}
				}
			}
		}
	}

	/**
	 * Change the temperature setpoint of a room. Note: The executor should be stopped before manually calling this
	 * @param room in which to change the setpoint
	 * @param setPoint the new float value in degrees
	 */
	public void changeSetPoint(String room, double setPoint){
		mCurrentSetPointsMap.put(room, setPoint);
	}

	/**
	 * A simple container class for facilitating the PI control of several heaters
	 * @author M. Svendsen
	 */
	private class ControllerState{
		private double mError = 0;
		private double mLastError = 0;
		private double mIntegral = 0;
		private double mOutput = 0;
		private double mSaturationSignal = 0;

		/**
		 * Container class to monitor sensor updates
		 */
		private class SensorTracker{
			public double mValue = 0.0f;
			public boolean mUpdated = false;
		}
		private final RoomConfig mRoom;
		private HashMap<String, SensorTracker> mTemperatureTrackers = new HashMap<String, SensorTracker>();
		private double mAverageTemperature;

		/**
		 * Initializes a thermostatic controller for a given room
		 * @param the room in which this controller is to function
		 */
		public ControllerState(RoomConfig room){
			mRoom = room;

			for (SensorConfig sensor : room.getSensors()){
				if (sensor.getType() == DeviceType.Temperature){
					mTemperatureTrackers.put(sensor.getName(), new SensorTracker());
				}
			}
		}


		/**
		 * Called when a temperature sensor sends an update
		 * @param sensor which sent this event
		 * @param value the new temperature measured by this sensor
		 * @return a control signal or -999 indicating insufficient temperature readings.
		 */
		public double update(Sensor sensor, double value){
			// Match this updated sensor to one of the saved sensors
			SensorTracker trackerToUpdate = mTemperatureTrackers.get(sensor.getName());
			trackerToUpdate.mValue = value;
			trackerToUpdate.mUpdated = true;

			// Check if all the sensors in this room has been updated
			boolean allUpdated = true;
			for (SensorTracker tracker : mTemperatureTrackers.values()){
				if (!tracker.mUpdated){
					allUpdated = false;
					break;
				}
			}

			// Return a controlsignal or an error code if one or more sensors in this room have not yet been updated.
			if (allUpdated){
				// Compute average temperature in room
				mAverageTemperature = 0.0;
				for (SensorTracker tracker : mTemperatureTrackers.values()){
					mAverageTemperature += tracker.mValue;

					// Clear the updated flags
					tracker.mUpdated = false;
				}
				mAverageTemperature /= mTemperatureTrackers.size();
				return updateInternal(mAverageTemperature);
			}
			else{
				// Still missing some sensors, return error code
				return ERROR_MISSING_UPDATES;
			}
		}
		
		/**
		 * @return the average temperature inside the room controlled by this particular ControllerState object
		 */
		public double getAverageTemperature(){
			return mAverageTemperature;
		}
		
		/**
		 * Resets the integral. This should be called whenever a major change in setpoint is introduced.
		 */
		public void resetIntegral(){
			mIntegral = 0;
			mSaturationSignal = 0;
		}

		/**
		 * Updates a given controller.
		 * @param a new sample of the temperature in the given room
		 * @return a double representing the control signal to be sent
		 */
		public double updateInternal(double value){
			// Retrieve the current setpoint for this room and calculate the error
			mError = mCurrentSetPointsMap.get(mRoom.getRoomName()) - value; 

			// Apply proportional control
			mOutput = (Kp * mError); 

			// Apply integral control
			mIntegral += (mError) + (mSaturationSignal/Tt)/Ki;
			mOutput += (Ki * mIntegral);

			// Apply derivative control
			double derivative = mError - mLastError;
			mOutput += Kd*derivative;

			// Remember this error for next update
			mLastError = mError;

			// Save the magnitude of the control signal before saturation
			// and saturate it to obtain the final output signal
			double beforeSaturation = mOutput;
			if (mOutput > MAX_ACTUATION){
				mOutput = MAX_ACTUATION;
			}
			else if (mOutput < MIN_ACTUATION){
				mOutput = MIN_ACTUATION;
			}

			// Calculate saturated integral error
			mSaturationSignal = mOutput - beforeSaturation;
			return mOutput;
		}
	}


	/**
	 * Class responsible for handling the visualization associated with the default thermostatic controller
	 * @author M. Svendsen
	 */
	private class ThermostaticVisualizationHandler implements DocumentPreparedListener, VisualizationEventListener{
		// Constants
		public static final String PATH = "media/DefaultThermostaticVis.svg";
		public static final String TEMPERATURES_ID = "text3183-4-8";
		public static final String MIN_TEMP_ID = "rect3084";
		public static final String MAX_TEMP_ID = "rect3084-82";
		public static final String SLIDER_ID = "rect3123";
		public static final String SETPOINT_ID = "tspan3127";
		public static final String SETTING_ID = "text3205";
		public static final String INDICATOR_ID = "rect3048";
		public static final String PLOT_ID = "timeplot-1";
		public static final String AUTOMATIC_ID = "rect3123-0";
		public static final String OFF_ID = "rect3123-0-4";
		public static final int TIMEFRAME = 5*3600;

		private ControlType mCurrentControlType = ControlType.Automatic;

		private HashMap<String, String> mRoomMap = new HashMap<String, String>();
		private SVGDocument mDocument;
		private boolean mInitialized = false;
		private VisualizationHolder mVisualizationHolder;
		private String mDraggingId = "";
		private TimePlot mTimePlot;

		public ThermostaticVisualizationHandler(VisualizationHolder holder) {
			mVisualizationHolder = holder;

			mVisualizationHolder.setOnDocumentPreparedListener(this);
			mVisualizationHolder.setOnVisualizationEventListener(this);
		}

		@Override
		public void onVisualizationEvent(VisualizationEvent event) {	
			if (!mInitialized) return;

			if (event.getEventType() == EventType.Mouse_Pressed){
				// Check what element was clicked
				final double x = event.getPoint().getX();
				final double y = event.getPoint().getY();

				String matchingElementId = "";
				// Include information about what element the user might have pressed down on
				NodeList list = mDocument.getElementsByTagName("*");
				for (int i=0; i<list.getLength(); i++){
					Node node = list.item(i);

					NamedNodeMap attrs = node.getAttributes();

					if (attrs != null && attrs.getNamedItem("x") != null && attrs.getNamedItem("y") != null &&
							attrs.getNamedItem("width") != null && attrs.getNamedItem("height") != null){

						// Check X
						int xstart = Double.valueOf(attrs.getNamedItem("x").getNodeValue()).intValue();
						int xend = xstart + Double.valueOf(attrs.getNamedItem("width").getNodeValue()).intValue();

						if (xstart <= x && x <= xend){
							// Check Y
							int ystart = Double.valueOf(attrs.getNamedItem("y").getNodeValue()).intValue();
							int yend = ystart + Double.valueOf(attrs.getNamedItem("height").getNodeValue()).intValue();

							if (ystart <= y && y <= yend){
								// We have a match! Make sure this isnt just the background
								int width = Double.valueOf(mDocument.getElementsByTagName("svg").item(0).getAttributes().getNamedItem("width").getNodeValue()).intValue();
								int height = Double.valueOf(mDocument.getElementsByTagName("svg").item(0).getAttributes().getNamedItem("height").getNodeValue()).intValue();

								if (width > xend-xstart + 10 || height > yend-ystart +10){
									// Accept it
									String id = attrs.getNamedItem("id").getTextContent();

									if (id.equals(SLIDER_ID)){
										matchingElementId = id;
										break;
									}
								}
							}
						}
					}
				}
				if (!matchingElementId.equals("")){
					mDraggingId = matchingElementId;
				}
				else return;

				// Change color of element whilst dragging
				VisualizationUpdate update = getUpdate();
				update.addCommand(mDraggingId, "style:fill", "#00ff00");
				mControllerManager.executeVisualizationUpdateInternal(update);
			}
			else if (event.getEventType() == EventType.Mouse_Release){
				// Reset color of element
				VisualizationUpdate update = getUpdate();
				update.addCommand(mDraggingId, "style:fill", "#ff0000");
				mControllerManager.executeVisualizationUpdateInternal(update);
				mDraggingId = "";
			}

			// Check if the user is dragging
			else if (event.getEventType() == EventType.Mouse_Drag){
				if (mDraggingId.equals("")) return;

				// Calculate new position
				double y = event.getPoint().getY();

				// Check limits
				double ymax = Double.valueOf(mDocument.getElementById(MAX_TEMP_ID).getAttributeNode("y").getValue());
				double ymin = Double.valueOf(mDocument.getElementById(MIN_TEMP_ID).getAttributeNode("y").getValue());
				double height = Double.valueOf(mDocument.getElementById(SLIDER_ID).getAttributeNode("height").getValue());

				if (ymin > y){
					// User is dragging outside of limits.
					y = ymin;
				}
				else if (ymax < y+height){
					// User is dragging outside of limits
					y = ymax-height;
				}

				// Get the new setpoint value
				double setpoint = 30-(20/(ymax-ymin))*(y+height/2-ymin);

				// Change type of control
				changeControlType(ControlType.Manual);

				// Change setpoint for all rooms
				for (RoomConfig room : mBuildingConfig.getRooms()){
					changeSetPoint(room.getRoomName(), (float)setpoint);
				}

				// Update position of shape based on drag
				VisualizationUpdate update = getUpdate();
				update.addCommand(SETPOINT_ID, String.format("%.2f", setpoint));
				update.addCommand(mDraggingId, "y", String.valueOf((int)y));
				mControllerManager.executeVisualizationUpdateInternal(update);
			}
			else if (event.getEventType() == EventType.Mouse_Click){
				// Check if automatic button was clicked
				final double x = event.getPoint().getX();
				final double y = event.getPoint().getY();

				double xbutton = Double.valueOf(mDocument.getElementById(AUTOMATIC_ID).getAttributeNode("x").getValue());
				double ybutton = Double.valueOf(mDocument.getElementById(AUTOMATIC_ID).getAttributeNode("y").getValue());
				double widthbutton = Double.valueOf(mDocument.getElementById(AUTOMATIC_ID).getAttributeNode("width").getValue());
				double heightbutton = Double.valueOf(mDocument.getElementById(AUTOMATIC_ID).getAttributeNode("width").getValue());

				if (xbutton <= x && x <= xbutton+widthbutton && ybutton <= y && y <= ybutton+heightbutton){
					// The button automatic button was clicked
					changeControlType(ControlType.Automatic);
				}
				else{
					xbutton = Double.valueOf(mDocument.getElementById(OFF_ID).getAttributeNode("x").getValue());
					ybutton = Double.valueOf(mDocument.getElementById(OFF_ID).getAttributeNode("y").getValue());
					widthbutton = Double.valueOf(mDocument.getElementById(OFF_ID).getAttributeNode("width").getValue());
					heightbutton = Double.valueOf(mDocument.getElementById(OFF_ID).getAttributeNode("width").getValue());
					
					if (xbutton <= x && x <= xbutton+widthbutton && ybutton <= y && y <= ybutton+heightbutton){
						// The button automatic button was clicked
						changeControlType(ControlType.Off);
						
						// Change setpoint for all rooms
						for (RoomConfig room : mBuildingConfig.getRooms()){
							changeSetPoint(room.getRoomName(), 0);
						}
					}
				}
			}
		}

		@Override
		public void onDocumentReady(Document document) {
			mDocument = (SVGDocument) document;

			// Create a map that links room names to SVG ids
			VisualizationUpdate update = getUpdate();
			NodeList nodes = mDocument.getElementById(TEMPERATURES_ID).getChildNodes();
			int i = 0;
			for (RoomConfig room : mBuildingConfig.getRooms()){				
				String id = nodes.item(i++).getAttributes().getNamedItem("id").getTextContent();

				// Save the reference
				mRoomMap.put(room.getRoomName(), id);

				// Add command
				update.addCommand(id, room.getRoomName()+": NaN");
			}

			// Also update setpoint to its initial value
			update.addCommand(SETPOINT_ID, String.format("%.2f", mCurrentSetPointsMap.elements().nextElement().floatValue()));

			mControllerManager.executeVisualizationUpdateInternal(update);

			// TimePlot test
			mTimePlot = new TimePlot(DefaultThermostaticController.class.getSimpleName(), PLOT_ID, TIMEFRAME, mDocument);

			mInitialized = true;
		}

		/**
		 * Method for updating the displayed temperature of a room
		 * @param roomName a string representing the name whose temperature has changed
		 * @param value a double representing the new temperature of the room
		 */
		public void temperatureReceived(String roomName, double value){
			// Update Visualization
			VisualizationUpdate update = getUpdate();
			update.addCommand(mRoomMap.get(roomName), roomName+": "+String.format("%.2f", value));
			mControllerManager.executeVisualizationUpdateInternal(update);

			//TODO: Change this from being hardcoded to some kind of dial perhaps?
			if (roomName.equals("Living_Room")){
				update = mTimePlot.updatePlot(value);
				mControllerManager.executeVisualizationUpdateInternal(update);
			}
		}

		/**
		 * Internal method for switching the controller between automatic, manual or off
		 * @param ControlType - the type of control to use
		 */
		public void changeControlType(ControlType type){
			if (mCurrentControlType == type) return;

			VisualizationUpdate update = getUpdate();
			if (type == ControlType.Automatic){
				mExecutor = Executors.newSingleThreadScheduledExecutor();
				update.addCommand(AUTOMATIC_ID, "style:fill", "#00ff00");
				update.addCommand(OFF_ID, "style:fill", "#ff0000");

				// Also move the button to the standard position and reset temperature
				double y = Double.valueOf(mDocument.getElementById(INDICATOR_ID).getAttributeNode("y").getValue());
				double height = Double.valueOf(mDocument.getElementById(INDICATOR_ID).getAttributeNode("height").getValue());
				double sliderheight = Double.valueOf(mDocument.getElementById(SLIDER_ID).getAttributeNode("height").getValue());

				update.addCommand(SLIDER_ID, "y", String.valueOf(y+(height-sliderheight)/2));
				update.addCommand(SETPOINT_ID, String.format("%.2f", 23.0));
			}
			else if (type == ControlType.Manual){
				mExecutor.shutdownNow();
				update.addCommand(AUTOMATIC_ID, "style:fill", "#ff0000");
				update.addCommand(OFF_ID, "style:fill", "#ff0000");
			}
			else{
				// Off!
				mExecutor.shutdownNow();
				update.addCommand(AUTOMATIC_ID, "style:fill", "#ff0000");
				update.addCommand(OFF_ID, "style:fill", "#00ff00");
			}
			
			update.addCommand(SETTING_ID, type.toString());
			mCurrentControlType = type;
			mControllerManager.executeVisualizationUpdateInternal(update);
			
			for (ControllerState state : mControllerMap.values()){
				// Reset integrals to make sure that the new control type takes effect immediately
				state.resetIntegral();
			}
		}

		/**
		 * Convenience method to obtain a VisualizationUpdate
		 */
		private VisualizationUpdate getUpdate(){
			return new VisualizationUpdate(DefaultThermostaticController.class.getSimpleName());
		}
	}
}


